<?php
/**
 * Copyright (c) 2006-2008, Julien PORTALIER
 * http://featherphp.googlecode.com/
 *
 * Licensed under The MIT License
 * Redistributions of files must retain the above copyright notice.
 */

/**
 * Fakes a namespace.
 */
class ActiveRecord
{
	# exceptions
	const RecordNotFound = 1;
	const RecordNotSaved = 2;
	const InvalidRecord  = 3;
	
	# constants
	const ValidateRecord = true;
}

/**
 * Database Object Relational Mapping.
 *
 * Note: internal parameters are prefixed with an underscore.
 */
abstract class ActiveRecord_Base extends Object
{
	# relationships
	protected $_belongs_to              = array();
	protected $_has_many                = array();
	protected $_has_one                 = array();
	protected $_has_one_to_many         = array();
	protected $_has_and_belongs_to_many = array();
	protected $_associations            = array();
	
	# validation
#	public    $_errors      = null;
	
	# database
	public    $_name;
	protected $_config         = 'default';
	protected $_prefix;
	protected $_use_table;
	protected $_table;
	protected $_db;
	public    $_primary_key    = 'id';
	public    $_foreign_key;
	public    $_fields         = array();
	protected $_table_aliases  = array();
	
	# find defaults
	protected $_defaults       = array();
	protected $_default_fields = '*';
	protected $_conditions     = array();
	protected $_order;
	protected $_group;
	protected $_limit;
	protected $_page;
	protected $_process        = DBO::PROCESS_FULL;
	protected $_include        = array();
	protected $_extend         = true;
	
	/**
	 * Find scopes.
	 * 
	 * \code
	 * $User->named_scope('recent', array(
	 * 	'type'  => ':all',
	 * 	'order' => 'User.created ASC',
	 * 	'limit' => 10
	 * ));
	 * $User->find_recent(array('limit' => 25));
	 * \endcode
	 * 
	 * @todo
	 */
#	protected $_scopes = array(
#		'all'    => array('type' => ':all'),
#		'first'  => array('type' => ':first', 'order' => 'created ASC'),
#		'last'   => array('type' => ':first', 'order' => 'created DESC'),
#		'recent' => array('type' => ':all',   'order' => 'created DESC', 'limit' => 10),
#	);
	
	/**
	 * Constructs the record.
	 * 
	 * Examples:
	 * 
	 * \code
	 * class Post extends ActiveRecord_Base { }
	 * 
	 * $Post = new Post();
	 * $Post = new Post($id);
	 * $Post = new Post(array('title' => $title, 'body' => $body));
	 * \endcode
	 * 
	 * @param $id_or_data Mixed[optional] Record ID (eg: existing record) or Hash of fields => values (eg: new record).
	 * @param $read_fields Mixed
	 */
	function __construct($id_or_data=null, $read_fields=null)
	{
		$this->_name = get_class($this);
		
		# db handler
		$this->_db = DBO::get($this->_config);
		
		# table
		if ($this->_use_table !== false)
		{
			$this->_table = empty($this->_use_table) ? Inflector::tableize($this->_name) : $this->_use_table;
			if (!empty($this->_prefix)) {
				$this->_table = $this->_prefix.$this->_table;
			}
			
			# fields
			$this->_fields = memory_cache("active_record/{$this->_table}", null, DEBUG ? '+30 seconds' : '+1 day');
			if (empty($this->_fields))
			{
				$this->_fields = $this->_db->describe_table($this->_table);
				memory_cache("active_record/{$this->_table}", &$this->_fields);
			}
		}
		
		# FK
		if (empty($this->_foreign_key)) {
			$this->_foreign_key = Inflector::underscore($this->_name).'_'.$this->_primary_key;
		}
		
		# associations
		$this->_belongs_to = array_merge($this->_belongs_to, $this->_has_and_belongs_to_many);
		$this->_has_one    = array_merge($this->_has_one,    $this->_has_one_to_many);
		$this->_has_many   = array_merge($this->_has_many,   $this->_has_and_belongs_to_many, $this->_has_one_to_many);
		$this->set_association('belongs_to');
		$this->set_association('has_one');
		$this->set_association('has_many');
		
		# table aliases
		$this->_table_aliases = array($this->_table => $this->_name);
		foreach($this->_associations as $name => $conf) {
			$this->_table_aliases[$conf['table']] = $name;
		}
		
		# defaults for finds
		if (!empty($this->_default_fields)) $this->_defaults['fields']     = $this->_default_fields;
		if (!empty($this->_conditions))     $this->_defaults['conditions'] = $this->_conditions;
		if (!empty($this->_order))          $this->_defaults['order']      = $this->_order;
		if (!empty($this->_group))          $this->_defaults['group']      = $this->_group;
		if (!empty($this->_limit))          $this->_defaults['limit']      = $this->_limit;
		if (!empty($this->_page))           $this->_defaults['page']       = $this->_page;
		if (!empty($this->_include))        $this->_defaults['include']    = $this->_include;
		if (!empty($this->_extend))         $this->_defaults['extend']     = $this->_extend;
		if (!empty($this->_process))        $this->_defaults['process']    = $this->_process;
		
		# read or set data
		if (!empty($id_or_data))
		{
			if (is_array($id_or_data))
			{
				# set data
				$this->set(&$id_or_data);
			}
			else
			{
				# existing record: read data for id
				$this->read($id_or_data, $read_fields);
			}
		}
		else
		{
			# new record
			$this->set_defaults();
		}
	}
	
	/**
	 * Magical function to read a field's value.
	 */
	function __get($attr)
	{
		# field value
#		if (isset($this->_data[$attr])) {
		if (array_key_exists($attr, $this->_data)) {
			return $this->_data[$attr];
		}
		
		# field value but unloaded (yet)
		elseif (array_key_exists($attr, $this->_fields) and isset($this->id)) {
			return $this->_data[$attr] = $this->find_field_by_id($this->id, $attr);
		}
		
		# association
		elseif (isset($this->_associations[$attr]))
		{
			if (class_exists($attr, false) or Load::model($attr)) {
				return $this->$attr = new $attr();
			}
		}
		
		# errors
		if ($attr == '_errors')
		{
			require_once 'models'.DS.'errors.php';
			require_once 'config'.DS.'validations.php';
			return $this->_errors = new ActiveRecord_Errors($this);
		}
	}
	
	/**
	 * Magical find methods.
	 * 
	 * \code
	 * # find by field/value
	 * $Record->find_by_field(':first', $value) translates to
	 *   $Record->find(':first', array('conditions' => array('field' => $value)));
	 * $Record->find_by_id($id);
	 * 
	 * # find by type
	 * $Record->find_all()   translates to $Record->find(':all')
	 * $Record->find_first() translates to $Record->find(':first')
	 * $Record->find_count() translates to $Record->find(':count')
	 * $Record->find_field() translates to $Record->find(':field')
	 * 
	 * # complex: find by type + field/value
	 * $Record->find_all_by_tag($tag, &$options);
	 * \endcode
	 * 
	 * @return Mixed
	 * @param $func String
	 * @param $args Array
	 */
	function __call($func, array $args)
	{
		if (preg_match("/find_(all|first|count|field|list|assoc|)(?:_?by_(.+)|)/", $func, $match))
		{
			# find_:type
			if (!empty($match[1])) {
				$type = ":{$match[1]}";
			}
			elseif (in_array($args[0], array(':all', ':first', ':field', ':count', ':list', ':assoc'))) {
				$type = array_shift($args);
			}
			
			# find_by_$field
			if (isset($match[2])) {
				$conditions = array($match[2] => array_shift($args));
			}
			
			# find_:field
			if (isset($type) and $type == ':field') {
				$fields = array_shift($args);
			}
			
			# options
			$options = $this->extract_options($args);
			
			# particular conditions
			if (isset($conditions))
			{
				$options['conditions'] = empty($options['conditions']) ?
					$conditions :
					array_merge($options['conditions'], &$conditions);
			}
			if (isset($fields)) {
				$options['fields'] = $fields;
			}
			
			# puts optional type back in place
			$args = array(&$options);
			if (isset($type)) {
				array_unshift($args, $type);
			}
			
			$rs = call_user_func_array(array($this, 'find'), &$args);
			return $rs;
		}
		
		trigger_error("No such method: ".get_class($this)."::$func()", E_USER_ERROR);
	}
	
	
	// READ
	
	/**
	 * Finds results.
	 * 
	 * \code
	 * # find all:
	 * $Post->find(':all');
	 * $Post->find(':all', 'Post.title, Post.body');
	 * $Post->find(':all', 'Post.title, Post.body', array('process' => DBO::PROCESS_FULL));
	 * 
	 * $conditions = array('id' => $_GET['id']);
	 * $options    = array(
	 *   'conditions' => &$conditions,
	 *   'fields'     => 'Post.id, Post.title',
	 *   'process'    => DBO::PROCESS_ASSOC_LIST
	 * );
	 * $Post->find(':all', &$options);
	 * 
	 * # find one:
	 * $Post->find();
	 * $Post->find(':first');
	 * $Post->find(array('limit' => 1));
	 * \endcode
	 * 
	 * @todo Add support for: 'include' => array('User' => array('foreign_key' => 'visitor_id'));
	 * 
	 * @return Array of Records.
	 */
	function & find()
	{
		$options = array();
		$type    = ':first';
		
		# variable arguments
		if (func_num_args() > 0)
		{
			$args = func_get_args();
			
			# special find (1)
			if (is_string($args[0]) and strpos($args[0], ':') === 0) {
				$type = array_shift($args);
			}
			
			# options
			$options = $this->extract_options($args);
			$options = array_merge($this->_defaults, $options);
			
			# special find (2)
			switch($type)
			{
				case ':all':
					break;
				
				case ':first':
					$options['limit'] = 1;
					break;
				
				case ':field':
					$options['limit']   = 1;
					$options['extend']  = false;
					$options['process'] = DBO::PROCESS_VALUE;
					break;
				
				case ':count':
					$options['fields']  = 'COUNT(*)';
					$options['limit']   = 1;
					$options['extend']  = false;
					$options['process'] = DBO::PROCESS_VALUE;
					break;
				
				case ':list':
					$options['extend']  = false;
					$options['process'] = DBO::PROCESS_LIST;
					break;
				
				case ':assoc':
					$options['extend']  = false;
					$options['process'] = DBO::PROCESS_ASSOC_LIST;
					break;
				
				default:
					trigger_error("Unknown find type: $type.", E_USER_WARNING);
			}
		}
		
		# table
		$options['from']    = $this->_table;
		$options['from_as'] = $this->_name;
		
		#$options['table_aliases'] = array($this->_table => $this->_name);
		
		# associations
		if (isset($options['include']))
		{
			$include = array_collection($options['include']);
			unset($options['include']);
		}
		if (isset($options['extend']))
		{
			$extend = $options['extend'];
			unset($options['extend']);
		}
		else {
			$extend = $this->_default_extend;
		}
		
		# joins
		if (!empty($include))
		{
			$_left_join = array();
			foreach($include as $i => $assoc)
			{
				if (strpos($assoc, 'LEFT ') === 0)
				{
					$assoc = trim(substr($assoc, 5));
					$_left_join[$assoc] = true;
					$include[$i] = $assoc;
				}
			}
			
			# SELECT FROM tags AS Tag JOIN posts AS Post WHERE Post.id = Tag.post_id 
			$belongs_to = array_intersect($include, array_keys($this->_belongs_to));
			foreach($belongs_to as $join)
			{
				$options['joins'][] = array(
					'type'  => isset($_left_join[$join]) ? 'LEFT' : 'INNER',
					'table' => $this->$join->_table,
					'as'    => $this->$join->_name,
					'on'    => array(
						"{$join}.{$this->$join->_primary_key}" =>
						"{$this->_name}.{$this->_belongs_to[$join]['foreign_key']}"
					),
				);
				#$options['table_aliases'][$this->$join->_table] = $this->$join->_name;
			}
			
			# SELECT FROM post AS Post JOIN tags AS Tag WHERE Post.id = Tag.post_id 
			$has_one = array_intersect($include, array_keys($this->_has_one));
			foreach($has_one as $join)
			{
				$options['joins'][] = array(
					'type'  => isset($_left_join[$join]) ? 'LEFT' : 'INNER',
					'table' => $this->$join->_table,
					'as'    => $this->$join->_name,
					'on'    => array(
						"{$this->_name}.{$this->_primary_key}" =>
						"{$join}.{$this->_foreign_key}"
					),
				);
				#$options['table_aliases'][$this->$join->_table] = $this->$join->_name;
			}
		}
		
		# process
		$process = $options['process'];
		unset($options['process']);
		
		# finds
		$options['table_aliases'] = $this->_table_aliases;
		$rs = $this->_db->select(&$options, $process);
		
		# extends results
		if ($extend and !empty($rs)
			and ($process == DBO::PROCESS_FULL or  $process == DBO::PROCESS_LITE))
		{
			$rs = $this->__extend_results($rs);
		}
		
		/*
		# recursive (associations)
		if (!empty($include))
		{
			$has_many = array_intersect($include, array_keys($this->_has_many));
			foreach($has_many as $model)
			{
				foreach($rs as $i => $data)
				{
					$options = array(
						'conditions' => array(
							$model.'.'.$this->_has_many[$model]['foreign_key'] => $data->id
						)
					);
					$_model = Inflector::pluralize($model);
					$rs[$i]->$_model = $this->$model->find(&$options);
				}
			}
		}
		*/
		
		if ($process == DBO::PROCESS_VALUE)
		{
			# find a value
			return $rs;
		}
		elseif ($type != ':all' and $type != ':list')
		{
			# find one result
			$rs = isset($rs[0]) ? $rs[0] : $rs;
		}
		
		# find all
		return $rs;
	}
	
	/**
	 * Finds by SQL and extends resultset.
	 * 
	 * \code
	 * $users = $User->find_by_sql("SELECT * FROM users WHERE id = ?", $_GET['id']);
	 * \endcode
	 * 
	 * @return Array
	 * @param $sql Mixed
	 */
	function & find_by_sql($sql)
	{
		$this->_db->table_aliases = $this->_table_aliases;
		
		$args = func_get_args();
		$rs   = call_user_func_array(array($this->_db, 'select'), $args);
		$rs   = $this->__extend_results($rs);
		return $rs;
	}
	
	/**
	 * Counts matching records by SQL.
	 * 
	 * \code
	 * $users = $User->count_by_sql("SELECT COUNT(*) FROM users WHERE id = ?", $_GET['id']);
	 * \endcode
	 * 
	 * @return Integer
	 * @param $sql Mixed
	 */
	function count_by_sql($sql)
	{
		$args   = func_get_args();
		$args[] = DBO::PROCESS_VALUE;
		return call_user_func_array(array($this->_db, 'select'), $args);
	}
	
	/**
	 * Finds many results with pagination informations.
	 * 
	 * @return ArrayObject Found records with pagination's informations.
	 * @param $fields_or_options Mixed[optional]
	 * @param $limit Integer[optional]
	 * @param $page Integer[optional]
	 */
	function & paginate($fields_or_options='*', $limit, $page)
	{
		if (!is_array($fields_or_options))
		{
			$options = empty($fields_or_options) ? array() :
				array('fields' => $fields_or_options);
		}
		else
		{
			$options =& $fields_or_options;
		}
		
		# results
		$options['limit'] = $limit;
		if ($page !== false)
		{
			$options['page']  = $page;
			$rs = $this->find_all(&$options);
		
			# data for pagination
			if (count($rs) > 0)
			{
				$rs = new ArrayObject($rs);
				$rs->limit = $limit;
				$rs->page  = $page;
				
				unset($options['order']);
				unset($options['page']);
				$rs->count = $this->find(':count', &$options);
			}
		}
		else {
			$rs = $this->find_all(&$options);
		}
		return $rs;
	}
	
	/**
	 * Checks if a record exists.
	 * 
	 * @return Boolean
	 * @param $id_or_conditions Mixed ID of record, or conditions to match record.
	 */
	function exists($id_or_conditions)
	{
		$options = array(
			'conditions' => is_array($id_or_conditions) ?
				$id_or_conditions : $this->match_this($id_or_conditions)
		);
		return ($this->find(':count', &$options) > 0);
	}
	
	/**
	 * Reads a record from database.
	 * 
	 * \code
	 * # model
	 * class Post extends ActiveRecord {}
	 * 
	 * # controller
	 * $Post = new Post();
	 * $Post->read($id);
	 * 
	 * # view
	 * <h1><?= $Post->title ?></h1>
	 * <?= $Post->body ?>
	 * \endcode
	 * 
	 * Raises ActiveRecord::RecordNotFound if record doesn't exists.
	 * 
	 * @param $id Mixed[optional]
	 * @param $fields_or_options Mixed[optional]
	 */
	function read($id=null, $fields_or_options=null)
	{
		if (is_array($fields_or_options))
		{
			$options = $fields_or_options;
			$fields  = null;
		}
		else
		{
			$options = array();
			$fields  = $fields_or_options;
		}
		
		$options['conditions'] = $this->match_this($id);
		$options['extend']     = false;
		
		if (!empty($fields)) {
			$options['fields'] = $fields;
		}
		
		$rs = $this->find(':first', &$options);
		
		if (empty($rs)) {
			throw new ActiveRecord_Exception_RecordNotFound();
		}
		$this->set(&$rs);
#		$this->_new_record = false;
	}
	
	
	// VALIDATION
	
	/**
	 * Validates record's data.
	 * 
	 * @return Boolean Success?
	 * @param $type  String[Optional]  Can be either ':insert' or ':update'.
	 * @param $raise Boolean[Optional] Raises an ActiveRecord::InvalidRecord exception instead of returning false.
	 */
	function is_valid($type=null, $raise=true)
	{
		$this->_errors->clear();
		
		# validates record
		$this->validate();
		($type == ':create') ?
			$this->validate_on_create() :
			$this->validate_on_update();
		
		# validates associations (if any)
		foreach(array_keys($this->_associations) as $assoc)
		{
			if (isset($this->$assoc) and !$this->$assoc->is_valid($type, false)) {
				$invalid = true;
			}
		}
		
		# invalid record?
		if ((isset($invalid) and $invalid) or !$this->_errors->is_empty())
		{
			if ($raise) {
				throw new ActiveRecord_Exception_InvalidRecord();
			}
			return false;
		}
		
		# record is ok
		return true;
	}
	
	/**
	 * Validates record's data.
	 */
	protected function validate() { }
	
	/**
	 * Validates record's data on creation only.
	 */
	protected function validate_on_create() { }
	
	/**
	 * Validates record's data on update only.
	 */
	protected function validate_on_update() { }
	
	
	// SAVE: create / update
	
	/**
	 * Saves the record, choosing if it must be created or updated.
	 * 
	 * \code
	 * # creates a new record
	 * $User = new User(&$_POST['User']);
	 * $User->save();
	 * 
	 * # updates an existing record 
	 * $User = new User($id);
	 * $User->name = 'Ryan';
	 * $User->save();
	 * \endcode
	 * 
	 * @return Boolean.
	 * @param $validate Boolean[optional]
	 * @param $type String[optional] Either :create or :update
	 */
	function save($validate=true, $type=null)
	{
		# type
		if ($type === null) {
			$type = empty($this->{$this->_primary_key}) ?  ':create' : ':update';
		}
		elseif ($type != ':create' and $type != ':update') {
			trigger_error("Unknown save type: $type. Only :create and :update are accepted.", E_USER_ERROR);
		}
		
		# validation
		if ($validate)
		{
			try {
				$this->is_valid($type, ActiveRecord::ValidateRecord);
			}
			catch(ActiveRecord_Exception $e)
			{
				if ($e->getCode() == ActiveRecord::InvalidRecord) {
					return false;
				}
			}
		}
		
		# save time
		$time_field = ($type == ':create') ? 'created' : 'modified';
		if (empty($this->_data[$time_field])
			and array_key_exists($time_field, $this->_fields))
		{
			$this->_data[$time_field] = Time::SQL();
		}
		
		# saves record
		if ($this->before_save($type))
		{
			# associations (if any)
			$associations = $this->_associations;
			foreach(array_keys($associations) as $assoc)
			{
				if (!isset($this->$assoc)) {
					unset($associations[$assoc]);
				}
			}
			if (!empty($associations)) {
				$this->_db->transactions('begin');
			}
			
			$data = array_intersect_key($this->_data, $this->_fields);
			
			# CREATE
			if ($type == ':create')
			{
#				$new_id = $this->_db->insert($this->_table, $data, $this->_primary_key);
#				
#				# sets ID
#				if ($new_id and !isset($this->{$this->_primary_key})) {
#					$this->{$this->_primary_key} = $new_id;
#				}
				if (!isset($this->{$this->_primary_key}))
				{
					$new_id = $this->_db->insert($this->_table, $data, $this->_primary_key);
					if ($new_id)
					{
						# sets ID
						$this->{$this->_primary_key} = $new_id;
						$rs = true;
					}
					else {
						$rs = false;
					}
				}
				else {
					$rs = $this->_db->insert($this->_table, $data);
				}
			}
			# UPDATE
			else
			{
				$where = array($this->_primary_key => $this->{$this->_primary_key});
				$rs = $this->_db->update($this->_table, $where, $data);
			}
			
			# was save succesful?
			if ($rs)
			{
				# saves associated data (if any)
				foreach($associations as $assoc => $config)
				{
					if (!$this->$assoc->{$this->_foreign_key}) {
						$this->$assoc->{$this->_foreign_key} = $this->{$this->_primary_key};
					}
					if (!$this->$assoc->create(false))
					{
						$this->_db->transactions('rollback');
						return false;
					}
				}
				
				# finishes save
				if ($this->after_save($type))
				{
					if (!empty($associations)) {
						$this->_db->transactions('commit');
					}
					return true;
				}
			}
		}
		
		# an error occured
		if (!empty($associations)) {
			$this->_db->transactions('rollback');
		}
#		throw new ActiveRecord_Exception_RecordNotSaved();
		return false;
	}
	
	/**
	 * Saves a single field.
	 * 
	 * \code
	 * $User = new User($id);
	 * $User->name = 'Ryan';
	 * $success = $User->save_field('name');
	 * 
	 * # alternative
	 * $User = new User($id);
	 * $User->save_field('name', 'Ryan');
	 * \endcode
	 * 
	 * @param $field    String
	 * @param $value    Mixed[optional]
	 * @param $validate Boolean[optional]
	 */
	function save_field($field, $value=null, $validate=false)
	{
		# validation
		$validate and $this->is_valid(':update', true);
		
		# save
		$conditions = $this->match_this(null, false);
		$data = array(
			$field => is_null($value) ? $this->{$field} : $value
		);
		return $this->_db->update($this->_table, $conditions, $data);
	}
	
	/**
	 * Saves multiple fields.
	 * 
	 * \code
	 * $Post->save_fields('created, published');
	 * \endcode
	 * 
	 * @return boolean
	 * @param $fields Mixed
	 */
	function save_fields($fields)
	{
		$conditions = $this->match_this(null, false);
		$data = array();
		foreach(array_collection($fields) as $field) {
			$data[$field] = $this->$field;
		}
		return $this->_db->update($this->_table, $conditions, $data);
	}
	
	/**
	 * Creates a new record, and saves it into database, if it validates.
	 * 
	 * \code
	 * # creates a single record in database
	 * $User    = new User(array('name' => 'John'));
	 * $success = $User->create();
	 * 
	 * # another way to do it
	 * $User    = new User();
	 * $NewUser = $User->create(array('name' => 'John'));
	 * \endcode
	 * 
	 * @return Mixed
	 * @param $data Boolean[optional] Validate data before save?
	 * @param $validate Boolean[optional] Validate data before save?
	 */
	function create($data=null, $validate=true)
	{
		# creates a new record
		if (is_array($data))
		{
			$class = $this->_name;
			$Record = new $class();
			$Record->set($data);
			return $Record->save($validate, ':create') ? $Record : false;
		}
		
		# creates this record in database
		return $this->save($validate, ':create');
	}
	
	/**
	 * Creates many records at once.
	 * 
	 * \code
	 * $users = $User->create(array(array('name' => 'Ryan'), array('name' => 'John'));
	 * \endcode
	 * 
	 * @param $records Array
	 * @param $validate Boolean[optional]
	 */
	function & create_all($records=null, $validate=true)
	{
		$objects = array();
		$class   = $this->_name;
		foreach($records as $data)
		{
			$Record    = new $class();
			$objects[] = $Record->create($data, $validate);
		}
		return $objects;
	}
	
	/**
	 * Updates an existing record.
	 * 
	 * \code
	 * $success = $User->update($_GET['id'], array('name' => 'John'));
	 * \endcode
	 * 
	 * @return Boolean
	 * @param $id Mixed
	 * @param $data Array[optional]
	 * @param $validate Boolean[optional]
	 */
	function update($id, $data=null, $validate=true)
	{
		if (is_array($data)) {
			$this->set($data);
		}
		$this->{$this->_primary_key} = $id;
		return $this->save($validate, ':update');
	}
	
	/**
	 * Updates many records at once.
	 * 
	 * \code
	 * # updates all records
	 * $Post->update_all(array('published' => 0));
	 * 
	 * # updates some records
	 * $Post->update_all(array('published' => 1), array('id' => array(1, 27, 43));
	 * \endcode
	 * 
	 * @param $data Array
	 * @param $conditions Mixed[optional]
	 * @param $options Array[optional] Supports 'order', 'page', and 'limit'.
	 */
	function update_all(array &$data, $conditions=null, $options=null)
	{
		return $this->_db->update($this->_table, $conditions, $data, $options);
	}
	
	/**
	 * Called before saving a record.
	 * 
	 * @return Boolean True on success.
	 * @param $type String Either :create or :update
	 */
	protected function before_save($type)
	{
		return true;
	}

	/**
	 * Called after saving a record.
	 * 
	 * @return Boolean True on success.
	 * @param $type String Either :create or :update
	 */
	protected function after_save($type)
	{
		return true;
	}
	
	
	// DELETION
	
	/**
	 * Deletes records by their id.
	 * 
	 * @return Boolean True on success.
	 * @param $id Mixed[optional]
	 */
	function delete($id=null)
	{
		if (!$this->before_delete($id)) {
			return false;
		}
		
		$conditions = $this->match_this($id, false);
		if ($this->_db->delete($this->_table, &$conditions)) {
			return $this->after_delete($id);
		}
		
		return false;
	}
	
	/**
	 * Deletes records that match condition(s).
	 * 
	 * @return Boolean True on success.
	 * @param $conditions Mixed
	 */
	function delete_all($conditions=null)
	{
		return $this->_db->delete($this->_table, $conditions);
	}
	
	/**
	 * Called before deletion.
	 * 
	 * @return Boolean True on success.
	 */
	protected function before_delete($id=null)
	{
		return true;
	}
	
	/**
	 * Called after deletion.
	 * 
	 * @return Boolean True on success.
	 */
	protected function after_delete($id=null)
	{
		return true;
	}
	
	
	// INTERNALS
	
	/**
	 * Extracts different params from find args.
	 * 
	 * @return Array
	 * @param $args Object
	 */
	protected function & extract_options(array &$args)
	{
		if (empty($args)) {
			$options = array();
		}
		elseif (is_array($args[0]))
		{
			# arg 1. options
			$options = array_shift($args);
		}
		else
		{
			# arg 1. fields
			# arg 2. options?
			$options = (isset($args[1]) and is_array($args[1])) ? $args[1] : array();
			$options['fields'] = array_shift($args);
		}
		return $options;
	}
	
	/**
	 * Creates conditions to match a record.
	 * 
	 * $where = $this->match_this($id);
	 * $this->_db->update($this->table, $where, $this->_data);
	 * 
	 * @return Array DB conditions to match a record.
	 * @param $id   Mixed Value of record.
	 * @param $full Boolean Use Model.primary_key or just primary_key form?
	 */
	protected function & match_this($id=null, $full=true)
	{
		if (is_null($id)) {
			$id = $this->{$this->_primary_key};
		}
		$key = $full ? "{$this->_name}.{$this->_primary_key}" : $this->_primary_key;
		$conditions = array($key => $id);
		return $conditions;
	}
	
	/**
	 * @param $assoc String Either one of 'belongs_to', 'has_one' or 'has_many'.
	 */
	private function set_association($assoc)
	{
		$assoc = "_$assoc";
		foreach($this->$assoc as $k => $v)
		{
			if (!is_array($v))
			{
				unset($this->{$assoc}[$k]);
				$k = $v;
				$this->{$assoc}[$k] = array();
			}
			
			# PK
			if (!isset($this->{$assoc}[$k]['primary_key'])) {
				$this->{$assoc}[$k]['primary_key'] = 'id';
			}
			
			# FK
			if (!isset($this->{$assoc}[$k]['foreign_key']))
			{
#				$this->{$assoc}[$k]['foreign_key'] = ($assoc == '_has_one' or $assoc == '_has_many') ?
#					Inflector::underscore($this->_name).'_'.$this->_primary_key :
#					Inflector::underscore($k).'_'.$this->{$assoc}[$k]['primary_key'];
				$this->{$assoc}[$k]['foreign_key'] = Inflector::underscore($k).'_'.$this->{$assoc}[$k]['primary_key'];
			}
			
			# table
			if (!isset($this->{$assoc}[$k]['table'])) {
				$this->{$assoc}[$k]['table'] = Inflector::tableize($k);
			}
			if (isset($this->{$assoc}[$k]['prefix'])) {
				$this->{$assoc}[$k]['table'] = $this->{$assoc}[$k]['prefix'].$this->{$assoc}[$k]['table'];
			}
			$this->_associations[$k] = $this->{$assoc}[$k];
		}
	}
	
	/**
	 * @deprecated
	 * @return Array
	 */
	function & get_associations()
	{
		$associations = array_keys($this->_associations);
		return $associations;
	}
	
	
	/**
	 * Returns the Record as XML.
	 * 
	 * @return String
	 */
	function to_xml()
	{
		$tag = Inflector::underscore(Inflector::singularize($this->_name));
		$xmlstring = "<$tag>";
		foreach($this->_data as $k => $v)
		{
			if ($this->_fields[$k]['type'] == 'text') {
				$v = "<![CDATA[$v]]>";
			}
			$xmlstring .= "<$k>$v</$k>\n";
		}
		foreach($this->get_associations() as $assoc)
		{
			if (isset($this->$assoc)) {
				$xmlstring .= $this->$assoc->to_xml();
			}
		}
		$xmlstring .= "</$tag>";
		return $xmlstring;
	}
	
	/**
	 * Returns the Record as JSON.
	 * 
	 * @return String
	 */
	function to_json()
	{
		return json_encode($this->_data);
	}
	
	function __toString()
	{
		ob_start();
		echo '<pre>';
		echo "<strong>{$this->_name}</strong>\n";
		foreach($this as $k => $v) {
			echo "\t'$k' => '$v'\n";
		}
		
		foreach($this->get_associations() as $assoc)
		{
			if (isset($this->$assoc))
			{
				echo "\n\t<strong>{$assoc}</strong>\n";
				foreach($this->$assoc as $k => $v) {
					echo "\t\t'$k' => '$v'\n";
				}
			}
		}
		echo '</pre>';
		return ob_get_clean();
	}
}
?>